/*
 * Copyright 2013 https://github.com/barthel
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package de.icongmbh.oss.maven.plugin.javassist;

import java.io.File;
import java.io.IOException;
import java.net.URLClassLoader;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;

import javassist.CannotCompileException;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtField;
import javassist.CtField.Initializer;
import javassist.LoaderClassPath;
import javassist.NotFoundException;
import javassist.build.IClassTransformer;
import javassist.build.JavassistBuildException;
import javassist.bytecode.AccessFlag;
import javassist.bytecode.ClassFile;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.filefilter.IOFileFilter;
import org.apache.commons.io.filefilter.SuffixFileFilter;
import org.apache.commons.io.filefilter.TrueFileFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Executor to perform the transformation by a list of {@link IClassTransformer} instances.
 *
 * @since 1.1.0
 */
public class JavassistTransformerExecutor {

  /**
   * Stamp field name prefix.
   */
  static final String STAMP_FIELD_NAME = "__TRANSFORMED_BY_JAVASSIST_MAVEN_PLUGIN__";

  private IClassTransformer[] transformerInstances = new IClassTransformer[0];

  private String inputDirectory;

  private String outputDirectory;

  private static final Logger LOGGER = LoggerFactory.getLogger(JavassistTransformerExecutor.class);

  public JavassistTransformerExecutor() {
    super();
  }

  /**
   * Configure class transformer instances for use with this executor.
   *
   * @param transformerInstances must not be {@code null}
   *
   * @throws NullPointerException if passed {@code transformerInstances} is {@code null}
   */
  public void setTransformerClasses(final IClassTransformer... transformerInstances) {
    this.transformerInstances = transformerInstances.clone();
  }

  /**
   * Sets the output directory where the transformed classes will stored.
   * <p>
   * The configured input directory will used if this directory is {@code null} or empty.
   * </p>
   *
   * @param outputDirectory could be {@code null} or empty.
   *
   * @see #setInputDirectory(String)
   */
  public void setOutputDirectory(final String outputDirectory) {
    this.outputDirectory = outputDirectory;
  }

  /**
   * Returns the output directory where the transformed classes will stored.
   *
   * @return maybe {@code null} or empty
   */
  protected String getOutputDirectory() {
    return outputDirectory;
  }

  /**
   * Sets the input directory where the classes to transform will selected from.
   * <p>
   * Nothing will transformed if this directory is {@code null} or empty.
   * </p>
   *
   * @param inputDirectory could be {@code null} or empty.
   */
  public void setInputDirectory(final String inputDirectory) {
    this.inputDirectory = inputDirectory;
  }

  /**
   * Returns the input directory where the the classes to transform will selected from.
   *
   * @return maybe {@code null} or empty
   */
  protected String getInputDirectory() {
    return inputDirectory;
  }

  /**
   * Executes all configured {@link IClassTransformer}.
   *
   * @see #setTransformerClasses(IClassTransformer...)
   * @see #execute(IClassTransformer)
   */
  public void execute() {
    for (final IClassTransformer transformer : transformerInstances) {
      execute(transformer);
    }
  }

  /**
   * Execute transformation with passed {@link IClassTransformer}.
   *
   * <p>
   * This method uses {@link #getInputDirectory() } and {@link #getOutputDirectory() } and calls
   * {@link #transform(IClassTransformer, String, String)}.
   * </p>
   * <p>
   * If the passed {@code transformer} is {@code null} nothing will transformed.
   * </p>
   *
   * @param transformer the transformer that will apply transformations could be {@code
   *         null}.
   *
   * @see #getInputDirectory()
   * @see #getOutputDirectory()
   * @see #transform(IClassTransformer, String, String)
   */
  protected void execute(final IClassTransformer transformer) {
    transform(transformer, getInputDirectory(), getOutputDirectory());
  }

  /**
   * Search for class files on the passed directory name ({@link #iterateClassnames(String)}) and
   * apply transformation to each one ( {@link #transform(IClassTransformer, String, String,
   * Iterator)}).
   *
   * <p>
   * <strong>Limitation:</strong> do not search inside .jar files.
   * </p>
   * <p>
   * If the passed {@code transformer} is {@code null} or the passed {@code directory} is {@code
   * null} or empty nothing will transformed.
   * </p>
   *
   * @param transformer the transformer that will apply transformations could be {@code
   *         null}.
   * @param directory could be {@code null} or empty. The input and output directory are the
   *         same.
   *
   * @see #iterateClassnames(String)
   * @see #transform(IClassTransformer, String, String, Iterator)
   */
  public final void transform(final IClassTransformer transformer, final String directory) {
    transform(transformer, directory, directory, iterateClassnames(directory));
  }

  /**
   * Search for class files on the passed input directory ({@link #iterateClassnames(String)}) and
   * apply transformation to each one ( {@link #transform(IClassTransformer, String, String,
   * Iterator)}).
   * <p>
   * <strong>Limitation:</strong> do not search inside .jar files.
   * </p>
   *
   * @param transformer The transformer that will apply transformations could be {@code
   *         null}.
   * @param inputDir The root directory where the classes to transform will selected from
   *         could be {@code null} or empty. If it is {@code null} or empty nothing will be
   *         transformed.
   * @param outputDir The output directory where the transformed classes will stored could
   *         be {@code null} or empty. If it is {@code null} or empty the {@code inputDir} will be
   *         used.
   *
   * @see #iterateClassnames(String)
   * @see #transform(IClassTransformer, String, String, Iterator)
   */
  public void transform(final IClassTransformer transformer,
                        final String inputDir,
                        final String outputDir) {
    transform(transformer, inputDir, outputDir, iterateClassnames(inputDir));
  }

  /**
   * Transform each class passed via {@link Iterator} of class names.
   * <p>
   * Use the passed {@code className} iterator, load each one as {@link CtClass}, filter the valid
   * candidates and apply transformation to each one.
   * </p>
   * <p>
   * <strong>Limitation:</strong> do not search inside .jar files.
   * </p>
   * <p>
   * Any unexpected (internal catched) {@link Exception} will be re-thrown in an {@link
   * RuntimeException}.
   * </p>
   *
   * @param transformer The transformer that will apply transformations could be {@code
   *         null}.
   * @param inputDir The root directory where the classes to transform will selected from
   *         could be {@code null} or empty. If it is {@code null} or empty nothing will be
   *         transformed.
   * @param outputDir The output directory where the transformed classes will stored could
   *         be {@code null} or empty. If it is {@code null} or empty the {@code inputDir} will be
   *         used.
   * @param classNames could be {@code null} or empty. If it is {@code null} or empty
   *         nothing will be transformed.
   *
   * @see #initializeClass(ClassPool, CtClass)
   * @see IClassTransformer#shouldTransform(CtClass)
   * @see IClassTransformer#applyTransformations(CtClass)
   */
  public final void transform(final IClassTransformer transformer,
                              final String inputDir,
                              final String outputDir,
                              final Iterator<String> classNames) {
    if (null == transformer) {
      return;
    }
    if (null == inputDir || inputDir.trim().isEmpty()) {
      return;
    }
    if (null == classNames || !classNames.hasNext()) {
      return;
    }
    final String inDirectory = inputDir.trim();
    try {
      final ClassPool classPool = configureClassPool(buildClassPool(), inDirectory);
      final String outDirectory = evaluateOutputDirectory(outputDir, inDirectory);
      int classCounter = 0;
      while (classNames.hasNext()) {
        final String className = classNames.next();
        if (null == className) {
          continue;
        }
        try {
          LOGGER.debug("Got class name {}", className);
          classPool.importPackage(className);
          final CtClass candidateClass = classPool.get(className);
          initializeClass(classPool, candidateClass);
          if (!hasStamp(transformer, candidateClass) && transformer
                  .shouldTransform(candidateClass)) {
            transformer.applyTransformations(candidateClass);
            applyStamp(transformer, candidateClass);
            // #48
            for (final CtClass nestedClass : candidateClass.getNestedClasses()) {
              if (!nestedClass.isModified() || hasStamp(transformer, nestedClass)) {
                continue;
              }
              final CtClass nestedCtClass = classPool.get(nestedClass.getName());
              initializeClass(classPool, nestedCtClass);
              applyStamp(transformer, nestedCtClass);
              nestedCtClass.writeFile(outDirectory);
            }
            candidateClass.writeFile(outDirectory);
            LOGGER.debug("Class {} instrumented by {}", className, getName(transformer));
            ++classCounter;
          }
        } catch (final NotFoundException e) {
          LOGGER.warn("Class {} could not be resolved due to dependencies not found on "
                      + "current classpath (usually your class depends on \"provided\""
                      + " scoped dependencies).", className);
        } catch (final IOException | CannotCompileException | JavassistBuildException ex) {
          // EOFException → IOException...
          LOGGER.error("Class {} could not be instrumented due to initialize FAILED.",
                       className,
                       ex);
        }
      }
      LOGGER.info("#{} classes instrumented by {}", classCounter, getName(transformer));
    } catch (final NotFoundException e) {
      throw new RuntimeException(e.getMessage(), e);
    }
  }

  /**
   * Evaluates and returns the output directory.
   *
   * <p>
   * If the passed {@code outputDir} is {@code null} or empty, the passed {@code inputDir} otherwise
   * the {@code outputDir} will returned.
   *
   * @param outputDir could be {@code null} or empty
   * @param inputDir must not be {@code null}
   *
   * @return never {@code null}
   *
   * @throws NullPointerException if passed {@code inputDir} is {@code null}
   * @since 1.2.0
   */
  protected String evaluateOutputDirectory(final String outputDir, final String inputDir) {
    return outputDir != null && !outputDir.trim().isEmpty() ? outputDir : inputDir.trim();
  }

  /**
   * Creates a new instance of a {@link ClassPool}.
   *
   * @return never {@code null}
   *
   * @since 1.2.0
   */
  protected ClassPool buildClassPool() {
    // create new classpool for transform; don't blow up the default
    return new ClassPool(ClassPool.getDefault());
  }

  /**
   * Configure the passed instance of a {@link ClassPool} and append required class pathes on it.
   *
   * @param classPool must not be {@code null}
   * @param inputDir must not be {@code null}
   *
   * @return never {@code null}
   *
   * @throws NotFoundException if passed {@code classPool} is {@code null} or if passed
   *         {@code inputDir} is a JAR or ZIP and not found.
   * @throws NullPointerException if passed {@code inputDir} is {@code null}
   * @since 1.2.0
   */
  protected ClassPool configureClassPool(final ClassPool classPool, final String inputDir)
          throws NotFoundException {
    classPool.childFirstLookup = true;
    classPool.appendClassPath(inputDir);
    classPool.appendClassPath(new LoaderClassPath(Thread.currentThread().getContextClassLoader()));
    classPool.appendSystemPath();
    debugClassLoader(classPool);
    return classPool;
  }

  /**
   * Search for class files (file extension: {@code .class}) on the passed {@code directory}.
   * <p>
   * Note: The passed directory name must exist and readable.
   * </p>
   *
   * @param directory must nor be {@code null}
   *
   * @return iterator of full qualified class names and never {@code null}
   *
   * @throws NullPointerException if passed {@code directory} is {@code null}.
   * @see SuffixFileFilter
   * @see TrueFileFilter
   * @see FileUtils#iterateFiles(File, IOFileFilter, IOFileFilter)
   * @see ClassnameExtractor#iterateClassnames(File, Iterator)
   */
  protected Iterator<String> iterateClassnames(final String directory) {
    final File dir = new File(directory);
    if (!dir.exists()) {
      return Collections.emptyIterator();
    }
    final String[] extensions = {".class"};
    final IOFileFilter fileFilter = new SuffixFileFilter(extensions);
    final IOFileFilter dirFilter = TrueFileFilter.INSTANCE;
    return ClassnameExtractor
            .iterateClassnames(dir, FileUtils.iterateFiles(dir, fileFilter, dirFilter));
  }

  /**
   * Apply a "stamp" to a class to indicate it has been modified.
   * <p>
   * By default, this method uses a boolean field named {@value #STAMP_FIELD_NAME} as the stamp. Any
   * class overriding this method should also override {@link #hasStamp(IClassTransformer,
   * CtClass)}.
   * </p>
   *
   * @param transformer The transformer that will apply transformations must not be {@code
   *         null}.
   * @param candidateClass the class to mark/stamp must not be {@code null}.
   *
   * @throws NullPointerException if passed {@code candidateClass} is {@code null}.
   * @throws CannotCompileException by {@link CtClass#addField(CtField,
   *         CtField.Initializer)}
   * @see #createStampField(IClassTransformer, CtClass)
   * @see CtClass#addField(CtField, CtField.Initializer)
   * @since 2.0.0
   */
  protected void applyStamp(IClassTransformer transformer, CtClass candidateClass)
          throws CannotCompileException {
    candidateClass
            .addField(createStampField(transformer, candidateClass), Initializer.constant(true));
  }

  /**
   * Remove a "stamp" from a class if the "stamp" field is available.
   * <p>
   * By default, this method removes a boolean field named {@value #STAMP_FIELD_NAME}. Any class
   * overriding this method should also override {@link #hasStamp(IClassTransformer, CtClass)}.
   * </p>
   *
   * @param transformer The transformer that will apply transformations must not be {@code
   *         null}.
   * @param candidateClass the class to remove the mark/stamp from must not be {@code null}
   *
   * @throws NullPointerException if passed {@code candidateClass} is {@code null}.
   * @throws CannotCompileException by {@link CtClass#removeField(CtField)}
   * @see #createStampField(IClassTransformer, CtClass)
   * @see CtClass#removeField(CtField)
   * @since 2.0.0
   */
  protected void removeStamp(IClassTransformer transformer, CtClass candidateClass)
          throws CannotCompileException {
    try {
      candidateClass.removeField(createStampField(transformer, candidateClass));
    } catch (final NotFoundException e) {
      // ignore; mission accomplished.
    }
  }

  /**
   * Indicates whether a class holds a stamp or not.
   * <p>
   * By default, this method uses a boolean field named {@value #STAMP_FIELD_NAME} as the stamp. Any
   * class overriding this method should also override {@link #applyStamp(IClassTransformer,
   * CtClass)} and {@link #removeStamp(IClassTransformer, CtClass) }.
   * </p>
   *
   * @param transformer The transformer that will apply transformations must not be {@code
   *         null}.
   * @param candidateClass the class to check must not be {@code null}.
   *
   * @return {@code true} if the class owns the stamp, otherwise {@code false}.
   *
   * @throws NullPointerException if passed {@code candidateClass} is {@code null}.
   * @see CtClass#getDeclaredField(String)
   * @since 2.0.0
   */
  protected boolean hasStamp(final IClassTransformer transformer, CtClass candidateClass) {
    boolean hasStamp;
    try {
      hasStamp = null != candidateClass.getDeclaredField(createStampFieldName(transformer));
    } catch (NotFoundException e) {
      hasStamp = false;
    }
    if (LOGGER.isDebugEnabled()) {
      LOGGER.debug("Stamp {}{} found in class {}",
                   createStampFieldName(transformer),
                   (hasStamp ? "" : " NOT"),
                   candidateClass.getName());
    }
    return hasStamp;
  }

  /**
   * Creates the name of the stamp field.
   * <p>
   * This implementation appends {@value #STAMP_FIELD_NAME} with the full qualified class name and
   * replaces all non-word characters (like '.') with '_'.
   * </p>
   *
   * @param transformer The transformer that will apply transformations must not be {@code
   *         null}.
   *
   * @return never {@code null} or empty.
   */
  private String createStampFieldName(final IClassTransformer transformer) {
    return STAMP_FIELD_NAME + transformer.getClass().getName().replaceAll("\\W", "_");
  }

  /**
   * Creates a {@link CtField} instance associated with the passed {@code candidateClass}.
   *
   * @param transformer The transformer that will apply transformations must not be {@code
   *         null}.
   * @param candidateClass must not be {@code null}
   *
   * @return never {@code null}
   *
   * @throws NullPointerException if passed {@code candidateClass} is {@code null}.
   * @throws CannotCompileException field could not created
   * @see CtField
   */
  private CtField createStampField(IClassTransformer transformer, final CtClass candidateClass)
          throws CannotCompileException {
    int stampModifiers = AccessFlag.STATIC | AccessFlag.FINAL;
    if (!candidateClass.isInterface()) {
      stampModifiers |= AccessFlag.PRIVATE;
    } else {
      stampModifiers |= AccessFlag.PUBLIC;
    }
    final CtField stampField = new CtField(CtClass.booleanType,
                                           createStampFieldName(transformer),
                                           candidateClass);
    stampField.setModifiers(stampModifiers);
    return stampField;
  }

  private void initializeClass(final ClassPool classPool, final CtClass candidateClass)
          throws NotFoundException {
    debugClassFile(candidateClass.getClassFile2());
    // TODO hack to initialize class to avoid further NotFoundException (what's the right way of
    // doing this?)
    candidateClass.subtypeOf(classPool.get(Object.class.getName()));
  }

  private String getName(IClassTransformer transformer) {
    return transformer.getClass().getName();
  }

  private void debugClassFile(final ClassFile classFile) {
    if (!LOGGER.isDebugEnabled()) {
      return;
    }
    LOGGER.debug(" - class: {}", classFile.getName());
    LOGGER.debug(" -- Java version: {}.{}",
                 classFile.getMajorVersion(),
                 classFile.getMinorVersion());
    LOGGER.debug(" -- interface: {} abstract: {} final: {}",
                 classFile.isInterface(),
                 classFile.isAbstract(),
                 classFile.isFinal());
    LOGGER.debug(" -- extends class: {}", classFile.getSuperclass());
    LOGGER.debug(" -- implements interfaces: {}", Arrays.deepToString(classFile.getInterfaces()));
  }

  private void debugClassLoader(final ClassPool classPool) {
    if (!LOGGER.isDebugEnabled()) {
      return;
    }
    LOGGER.debug(" - classPool: {}", classPool.toString());
    ClassLoader classLoader = classPool.getClassLoader();
    while (classLoader != null) {
      LOGGER.debug(" -- {}: {}", classLoader.getClass().getName(), classLoader.toString());
      if (classLoader instanceof URLClassLoader) {
        LOGGER.debug(" --- urls: {}", Arrays.deepToString(((URLClassLoader)classLoader).getURLs()));
      }
      classLoader = classLoader.getParent();
    }
  }

}
